"""
Experimental editor.

This editor includes a basic file menu, toolbar, project tree and edit area.
The edit area contains tabs, each containing an individual editor.
"""
import wx, sys, wx.aui, os
import wx.richtext as rt
import wx.lib.flatnotebook as fnb
import editors
from editors import file_openers
from util import *
from help_view import HelpViewer
from project import ProjectViewer, Project
from threading import Lock, Thread
from Queue import Queue, Empty
from subprocess import Popen, PIPE

def do_nothing(*args): pass

class MainMenu(wx.MenuBar):
	def __init__(self, win):
		super(MainMenu, self).__init__()
		app = win.app
		# Create a menu bar
		self.menubar = wx.MenuBar()

		self._next_id = 1

		def add_item(menu, label, desc, callback, image = None,
		             disabled = False):
			def f(event):
				callback()
			menu_id = self._next_id
			self._next_id += 1
			item = wx.MenuItem(menu, menu_id, label, desc)
			if image:
				item.SetBitmap(image)
			menu.AppendItem(item)
			app.Bind(wx.EVT_MENU, f, id=menu_id)
			if disabled: item.Enable(False)
			return item
		# TODO: Menu's next ID should (probably) be global!

		# File menu
		# TODO: dedup var names
		self.file_menu = file_menu = wx.Menu()
		
		# TODO: add real open and save functionality
		self.app_items = []
		add_item(file_menu,
		         'O&pen a File...\tCtrl+O',
		         'Open a file on your computer.',
		         app.open_file_prompt)
		file_menu.AppendSeparator()
		self.app_items.append(add_item(file_menu,
		                      '&Save This File\tCtrl+S',
		                      'Saves edited items.',
		                      win.save_file,
		                      disabled = True))
		self.app_items.append(add_item(file_menu,
		                      'Save This File &As...',
		                      'Saves edited items.',
		                      win.save_file_as,
		                      disabled = True))
		file_menu.AppendSeparator()
		add_item(file_menu,
		         'Sa&ve All Open Files',
		         'Saves edited items.',
		         app.save_all)
		add_item(file_menu,
		         'E&xit\tCtrl+Q',
		         'Quit this editor.',
		         app.exit)
		
		self.Append(file_menu,'&File')

		# Project menu
		project_menu = wx.Menu()
		add_item(project_menu,
		         '&New Project...',
		         'Create a new project.',
		         app.new_project)
		add_item(project_menu,
		         '&Open Project...',
		         'Open a project.',
		         app.open_project)
		# TODO: this just does the same thing that saving all files does.
		add_item(project_menu,
		         '&Save Project...',
		         'Save your project.',
		         app.save_all)
		# TODO: edit project properties
		self.Append(project_menu,'&Project')

		# Create menu
		create = wx.Menu()
		def make_factory_callback(factory):
			def f():
				try:
					factory(app)
				except UserCancelled:
					pass
			return f
		for factory in editors.editor_factories:
			add_item(create,
					 factory.title,
					 factory.title,
					 make_factory_callback(factory.factory),
					 factory.image)
		self.Append(create,'&Create')

	def enable_menu_items(self, enable = True):
		for item in self.app_items:
			item.Enable(enable)

class EditorApp(wx.App):
	"""
	Application controller.
	"""

	main_win = None # Main window
	project  = None # current project being edited
	editors  = []
	exiting  = False
	runner   = None
	# TODO: map edited objects to editors

	def __init__(self):
		# Prevent stdout, stderr from getting redirected to a window.
		super(EditorApp, self).__init__(False)

	def open_editor(self, editor):
		# TODO: needed?
		self.editors.append(editor)

	def close_editor(self, editor):
		self.editors.remove(editor)

	def OnInit(self):
		# Load the editors.
		editors.load_all()

		# Show the main window.
		self.main_win = MainWindow(self)
		self.SetTopWindow(self.main_win)
		self.main_win.Show()

		return True

	def exit(self, from_close_event = False):
		# TODO: check CanVeto

		if self.exiting:
			return
		self.exiting = True
		# Close all the editors first.
		for e in self.editors:
			e.close()

		# Save the open project.
		# TODO: merge project saving with file saving, and track project
		# modifications.
		if self.project:
			self.project.save()

		# Close the main window.
		self.main_win.Destroy()

	def save_all(self):
		"""
		Save all open editors.
		"""
		for editor in self.editors:
			editor.save()
		if self.project:
			self.project.save()

	def open_file_prompt(self):
		# TODO: get list of file types to open by extension.
		path = self.main_win.\
		       show_file_dialog(message = 'Pick a file to open.',
		                        wildcard = 'All Files (*.*)|*.*',
		                        is_saving = False)
		if not path: return
		self.open_file(path)

	def bind_project(self, project):
		"""
		Binds a project to the editor.
		"""
		# TODO: hide/show toolbar buttons
		# TODO: close existing project properly.
		self.project = project
		self.main_win.bind_project(project)
		self.main_win.main_bar.update_buttons()

	def new_project(self):
		"""
		Displays a prompt for a new project.
		"""
		# TODO: ask for project properties
		dlg = wx.DirDialog(self.main_win, "Choose a project folder.")
		try:
			if dlg.ShowModal() == wx.ID_OK:
				# TODO: instantiate from a config file, so 'main.py' is not
				# a hard-coded value.
				project = Project(dlg.GetPath())
				self.bind_project(project)
		finally:
			dlg.Destroy()

	def open_project(self):
		"""
		Displays a prompt for opening a project.
		"""
		dlg = wx.DirDialog(self.main_win, "Choose a project folder.")
		# TODO: disable 'New Folder' button
		# TODO: make sure a project actually exists
		try:
			if dlg.ShowModal() == wx.ID_OK:
				# TODO: instantiate from a config file, so 'main.py' is not
				# a hard-coded value.
				project = Project(dlg.GetPath())
				self.bind_project(project)
		finally:
			dlg.Destroy()

	def open_file(self, path):
		"""
		Attempts to open a file.
		"""
		for opener in file_openers:
			if opener(self, path):
				return

		# Warn user that the file couldn't be opened.
		msg_box = wx.MessageDialog(self.main_win,
		                           "I don't know how to open this file.",
		                           "Cannot Open File",
		                           wx.ICON_ERROR | wx.OK)
		msg_box.ShowModal()
		msg_box.Destroy()

	def _choose_python_file(self):
		"Asks the user for a Python file to run."
		return self.main_win.\
		       show_file_dialog(message = 'Pick a file to run.',
		                        wildcard = 'Python Files (*.py;*.pyw)|' +
		                                   '*.py;*.pyw',
		                        is_saving = False)

	def run_path(self, path):
		self.runner = PythonRunner(path,
		                           self.main_win.console,
		                           self._done_running)
		# TODO: don't "reach in" this far
		self.main_win.main_bar.update_buttons()

	def run_project(self, *args):
		# TODO: make sure previous runner exits properly
		if self.runner: return
		if not self.project:
			# TODO: just disable the run button.
			print "No project loaded."
			return
		if not self.project.file_to_run:
			# Ask user which file to run.
			# TODO: allow user to change file later.
			path = self._choose_python_file()
			if not path: return
			self.project.file_to_run = path
		self.run_path(self.project.file_to_run)

	def stop_project(self, *args):
		if not self.runner: return
		self.runner.stop()

	def _done_running(self):
		self.runner = None
		# TODO: don't "reach in" this far
		self.main_win.main_bar.update_buttons()

class MainToolbar(object):
	FACTORY_BUTTON_START = 100
	RUN_PROJECT          = 110
	STOP_PROJECT         = 120
	RUN_FILE             = 130

	# TODO: rethink this.  We should really try to encapsulate
	# editor-specific operations inside the corresponding editors.
	run_file_editor = None

	# TODO: we may want to use wx.Frame.CreateToolBar(flags) instead.
	def __init__(self, main_win):
		self.tb = main_win.CreateToolBar()
		self.tb.SetToolBitmapSize(wx.Size(44,32))
		self.main_win = main_win
		
		# Initialize the factory buttons
		tbID = self.FACTORY_BUTTON_START
		def make_toolbar_callback(factory):
			def f(event):
				try:
					factory.factory(main_win.app)
				except UserCancelled:
					pass
			return f
		for factory in editors.editor_factories:
			self.tb.AddLabelTool(tbID, factory.title, factory.large_image)
			main_win.Bind(wx.EVT_TOOL, make_toolbar_callback(factory), id=tbID)
			tbID += 1

		# TODO: rethink this Run button:
		#  * it only allows one running program session at a time.
		# TODO: dedup this code.
		self.tb.AddLabelTool(self.RUN_PROJECT, 'Run',
			wx.Bitmap(os.path.join(os.path.dirname(__file__),
			'images', 'run.png')))
		self.tb.EnableTool(self.RUN_PROJECT, False)
		main_win.Bind(wx.EVT_TOOL, main_win.app.run_project,
		              id=self.RUN_PROJECT)

		self.tb.AddLabelTool(self.RUN_FILE, 'Run File',
			wx.Bitmap(os.path.join(os.path.dirname(__file__),
			'images', 'run_file.png')))
		self.tb.EnableTool(self.RUN_FILE, False)
		main_win.Bind(wx.EVT_TOOL, main_win.run_file,
		              id=self.RUN_FILE)

		self.tb.AddLabelTool(self.STOP_PROJECT, 'Stop',
			wx.Bitmap(os.path.join(os.path.dirname(__file__),
			'images', 'stop.png')))
		self.tb.EnableTool(self.STOP_PROJECT, False)
		main_win.Bind(wx.EVT_TOOL, main_win.app.stop_project,
		              id=self.STOP_PROJECT)
		
		self.tb.Realize()

	def update_buttons(self):
		project_loaded = self.main_win.app.project         is not None
		running        = self.main_win.app.runner          is not None
		# TODO: get rid of this type check.
		run_file       = type(self.main_win.active_editor) is editors.code.CodeEditor

		self.tb.EnableTool(self.RUN_PROJECT , project_loaded and not running)
		self.tb.EnableTool(self.RUN_FILE    , not running and run_file)
		self.tb.EnableTool(self.STOP_PROJECT, running)

class Console(rt.RichTextCtrl):
	"""
	A simple widget that emulates a console window.
	It supports printing text with newlines, and reading text.

	Note: it will not print text while text is being read.
	"""

	def __init__(self, parent):
		super(Console, self).__init__(parent, style=wx.VSCROLL|wx.NO_BORDER)
		self._lock        = Lock()
		self._print_queue = Queue()
		self._line_queue  = Queue()
		self._timer       = wx.CallLater(100, self.update_text)
		self._init_style()
		self.SetEditable(False)
		self.Bind(rt.EVT_RICHTEXT_DELETE          , self._on_edit)
		self.Bind(rt.EVT_RICHTEXT_RETURN          , self._on_return)
		self.Bind(rt.EVT_RICHTEXT_CONTENT_INSERTED, self._on_edit)
		self.Bind(rt.EVT_RICHTEXT_CHARACTER       , self._on_char)
		self._prompt_pos = 0
		self._disable_edit_events = False
		self._text_to_insert = None

	def _on_return(self, event):
		self._line_queue.put(self.GetRange(self._prompt_pos,
		                                   self.GetLastPosition()))

	def _undo_edit(self):
		self._disable_edit_events = True
		self.Undo()
		if self._text_to_insert:
			self.MoveCaret(self._prompt_pos - 1)
			self.WriteText(self._text_to_insert)
			self._text_to_insert = None
		self._disable_edit_events = False

	def _on_char(self, event):
		# TODO: this assumes that _undo_edit will get called AFTER this
		# method.  Check this assumption.
		self._text_to_insert = event.Character

	def _on_edit(self, event):
		if self._disable_edit_events: return
		# Make sure the edit is after the existing text.
		# Otherwise, disallow it.
		# TODO: put the edit at the appropriate position.
		if event.Index < self._prompt_pos:
			self._text_to_insert = None
			wx.CallAfter(self._undo_edit)
			## TODO: make sure this works in edge cases (end of line, etc.)

	def _init_style(self):
		"Initializes the text control's style."
		attr = rt.RichTextAttr()
		attr.SetTextColour(wx.Colour(255,255,255))
		self.SetBackgroundColour(wx.Colour(0,0,0))
		font = wx.SystemSettings.GetFont(wx.SYS_SYSTEM_FIXED_FONT)
		attr.SetFontFaceName(font.GetFaceName())
		attr.SetFontSize(10)

		self.SetBasicStyle(attr)

	def __del__(self):
		# We need to halt the timer to prevent calls to a destroyed object.
		# TODO: test!
		self._timer.Stop()

	def _write(self, text, is_error = False):
		"""
		Appends text in the given style.
		"""
		if is_error: self.BeginTextColour((255,0,0))
		self.WriteText(text)
		self._prompt_pos = self.GetLastPosition()
		if is_error: self.EndTextColour()

	def write(self, text, is_error = False):
		"""
		Puts text into the print queue.
		"""
		self._print_queue.put((text, is_error))

	def update_text(self):
		"""
		Prints lines from the line queue periodically.
		"""
		# Attempt to acquire the lock on the control.
		if self._lock.acquire(False):
			self.Freeze()
			try:
				while True:
					args = self._print_queue.get_nowait()
					self._write(*args)
					self._print_queue.task_done()
			except Empty:
				pass
			finally:
				self.ScrollIntoView(self.GetLastPosition(), wx.WXK_DOWN)
				self.Thaw()
				self._lock.release()

		self._timer = wx.CallLater(100, self.update_text)

	def _begin_editing(self):
		self.DiscardEdits()
		self.SetEditable(True)
		self.SetFocus()

	def halt(self):
		"""
		Resets the console.
		Call this after a program using the console has shut down, to make sure
		the console won't continue to wait for input.
		"""
		# TODO: analyze the race conditions carefully!
		# Force any outstanding readline() to exit.
		self._line_queue.put(None)

	def readline(self):
		"""
		Reads a line of text from the console.
		This method will block until the line is read.
		"""
		try:
			# Wait for things to finish printing
			self._print_queue.join()
			# TODO: a line might get added for printing right here.
			# Semaphore semantics might be better...
			self._lock.acquire()
			# TODO: restrain the editing cursor
			wx.CallAfter(self._begin_editing)
			# Empty out the line queue
			try:
				while True: self._line_queue.get_nowait()
			except Empty:
				pass
			# Now wait for a line.
			line = self._line_queue.get()
			return line
		finally:
			self.SetEditable(False)
			self._lock.release()

class PythonRunner(object):
	"""
	Handles starting and stopping a process, and piping data between it and
	the console.
	"""
	def __init__(self, file_to_run, console, on_finish):
		"""
		Creates a new process running file_to_run, redirecting the output
		to console.  When the process completes, or stop() is called,
		on_finish() will be called.
		"""
		self.file_to_run = file_to_run
		self.console     = console
		self.on_finish   = on_finish

		# Create subprocess
		runner = os.path.join(os.path.dirname(__file__), 'runner.py')
		# TODO: read stderr separately.
		self.proc = Popen((sys.executable, runner, file_to_run),
		                  stdin=PIPE, stdout=PIPE)

		# TODO: start process to read the pipe
		# Start reading stdout and stderr.
		self.console.Clear()
		pipe_reader = Thread(target=self.read_proc_pipe, name='Pipe Reader')
		pipe_reader.daemon = True
		pipe_reader.start()

	def stop(self):
		"Stops the program."
		self.proc.terminate()
		self.console.halt()

	def read_proc_pipe(self):
		while self.proc.poll() is None:
			# Read the header to find out whether input or output is wanted
			header = self.proc.stdout.read(7)
			# TODO: be better prepared for unexpected shutdowns.
			if header == '##INPUT':
				# Read a line from the console and shove it into stdin.
				line = self.console.readline()
				#sys.stderr.write('Read line .%s.' % line)
				# If the program is closed, or console.halt() was called,
				# making line return None, then break the loop.
				if self.proc.stdin.closed or line is None:
					break
				self.proc.stdin.write(line)
				self.proc.stdin.flush()
			elif header == '###DONE':
				# Acknowledge termination, then break.
				# This makes sure we run long enough to get all program input.
				self.proc.stdin.write('\n')
				self.proc.stdin.flush()
				break
			else:
				# The header is the length of the text to write.
				try:
					size = int(header[1:])
					data = ''
					while len(data) < size:
						data += self.proc.stdout.read(size - len(data))
					if os.linesep != '\n':
						data = data.replace(os.linesep, '\n')
					self.console.write(data, header[0] == 'E')
				except ValueError:
					break # Terminate the loop if a bad header happens!
					# TODO: better handling?
		wx.CallAfter(self.on_finish)

class MainWindow(wx.Frame):
	"""
	The main editor window.  This window contains all the editors in tabs.
	"""
	tab_set = {} # maps editors to tab numbers
	editors = [] # maps tab numbers to editors
	active_editor = None
	ignore_tab_close = False

	def show_file_dialog(self, message = None,
	                     wildcard = 'All Files (*.*)|*.*',
	                     is_saving = True):
		if message is None:
			message = 'Choose where to save the file.' if is_saving else \
			          'Choose which file to open.'

		default_dir = self.app.project.path if self.app.project else \
		              os.getcwd()
		dlg = wx.FileDialog(self,
		                    defaultDir = default_dir,
		                    defaultFile = "",
		                    message = message,
		                    style = wx.SAVE if is_saving else wx.OPEN,
		                    wildcard = wildcard)

		if dlg.ShowModal() == wx.ID_OK:
			paths = dlg.GetPaths()
			if len(paths):
				return paths[0]
		return None

	def run_file(self, *args):
		"Runs the file in the current editor, after saving it."
		# TODO: most editors ask if you want to save the file, rather than
		# just saving it for you.
		if not self.active_editor.save(): return
		self.app.run_path(self.active_editor.path)

	def save_file(self):
		if not self.active_editor: return
		self.active_editor.save()

	def save_file_as(self):
		if not self.active_editor: return
		self.active_editor.save(save_as = True)

	def get_container(self):
		"""
		Returns the container for apps.
		"""
		# TODO: reconsider this method
		return self.tabs

	def bind_project(self, project):
		self.project_view.bind_project(project)

	def bind_editor(self, editor):
		"""
		Shows the given editor panel inside this window.
		"""
		self.tab_set[editor] = len(self.editors)
		self.editors.append(editor)

		self.app.open_editor(editor)
		self.tabs.AddPage(editor,editor.title)
		self.tabs.SetSelection(self.tabs.GetPageCount()-1)
		def f(title):
			self.tabs.SetPageText(self.tab_set[editor], title)
		editor.display_title = f
		f(editor.title)

		if self.active_editor:
			self.active_editor.blur()

		self.active_editor = editor
		self.active_editor.focus()

		self.menubar.enable_menu_items()

	def before_tab_change(self, event):
		self.menubar.enable_menu_items(False)
		if self.active_editor:
			self.active_editor.blur()
		self.active_editor = None
		self.main_bar.update_buttons()

	def after_tab_change(self, event):
		sel = self.tabs.GetSelection()
		if sel >= 0:
			self.active_editor = self.editors[sel]
			self.active_editor.focus()
			self.menubar.enable_menu_items()
			self.main_bar.update_buttons()

	def close_editor(self, editor, from_close_button=False):
		"""
		Closes the given editor.
		"""
		editor_index = self.tab_set[editor]
		del self.editors[editor_index]
		del self.tab_set[editor]
		for i in xrange(editor_index, len(self.editors)):
			self.tab_set[self.editors[i]] = i
		self.app.close_editor(editor)
		if not from_close_button:
			self.ignore_tab_close = True
			self.tabs.RemovePage(editor_index)
			self.ignore_tab_close = False
		self.menubar.enable_menu_items(False)
		# TODO: dedup with code in before_tab_change
		if self.active_editor:
			self.active_editor.blur()
		self.active_editor = None
		self.main_bar.update_buttons()

	def __init__(self, app, parent=None, id=-1):
		self.app = app

		# Initialize the frame
		wx.Frame.__init__(self, parent, id, "GameJockey Editor",size=(1200,800))

		# Add a status bar
		self.status_bar = wx.StatusBar(self)
		self.SetStatusBar(self.status_bar)
		
		# Use the AUI's FrameManager to manage the frame
		self._mgr = wx.aui.AuiManager()
		self._mgr.SetManagedWindow(self)
		
		# Set the minimum size of the editor
		self.SetMinSize(wx.Size(600, 400))
		
		# Create the menu
		self.menubar = MainMenu(self)
		self.SetMenuBar(self.menubar)
		
		# Create the notebook view
		self.tabs = fnb.FlatNotebook(self, wx.ID_ANY)
		self.Bind(fnb.EVT_FLATNOTEBOOK_PAGE_CHANGING, self.before_tab_change)
		self.Bind(fnb.EVT_FLATNOTEBOOK_PAGE_CHANGED, self.after_tab_change)
		self.Bind(fnb.EVT_FLATNOTEBOOK_PAGE_CLOSING, self.tab_closed)
		
		self._mgr.AddPane(self.tabs, wx.aui.AuiPaneInfo().Name("tabs")
		                             .CenterPane())

		# Add the toolbar
		self.main_bar = MainToolbar(self)
		#self._mgr.AddPane(self.main_bar, wx.aui.AuiPaneInfo()
		#                  .Name("tbMain").Caption("Main Toolbar")
		#                  .ToolbarPane().Top().Row(1)
		#                  .LeftDockable(False).RightDockable(False)
		#                  .Gripper(False))

		# Add the help viewer
		# TODO: make the help viewer closeable
		self.help_view = HelpViewer(self)
		self._mgr.AddPane(self.help_view, wx.aui.AuiPaneInfo()
		                  .Name("help_view").Caption("Help and Tutorials")
		                  .Right().MaximizeButton(True).CloseButton(False)
		                  .MinSize(wx.Size(300,20)).Layer(1))

		# Add the project viewer
		# TODO: make the project viewer closeable
		self.project_view = ProjectViewer(self)
		self._mgr.AddPane(self.project_view, wx.aui.AuiPaneInfo()
		                  .Name("project_view").Caption("Your Project Files")
		                  .Left().MaximizeButton(True).CloseButton(False)
		                  .MinSize(wx.Size(200,20)).Layer(1))

		# Add the application console
		# TODO: make it closeable
		self.console = Console(self)
		self._mgr.AddPane(self.console, wx.aui.AuiPaneInfo()
		                  .Name("console").Caption("Program Console")
		                  .Bottom().MaximizeButton(True).CloseButton(False)
		                  .MinSize(wx.Size(200,200)))

		# Commit changes to manager
		self._mgr.Update()

		# Send close events to the app
		def f(event): self.app.exit(from_close_event = True)
		self.Bind(wx.EVT_CLOSE, f)
		
		# Show the window
		self.Show(True)

	def tab_closed(self, event):
		if self.ignore_tab_close: return
		self.editors[event.GetSelection()].close(from_close_button = True)

	def OnQuit(self,event):
		# TODO: move to application?
		"Called when the window closes."
		for e in self.editors.itervalues():
			e.close()
		self.Close(True)

if __name__ == '__main__':
	EditorApp().MainLoop()
